Mooncake Classic vs TENT Engine
本文承接 Mooncake Classic NVMeoF Transport 与 Mooncake TENT GDS。前者展示旧后端怎样直接调用 cuFile,后者展示 TENT 怎样管理统一运行时;这里不再重复两篇文章的接口细节,只比较它们的对象边界。
“持有”究竟是什么
这里的“持有 Transport*”不是说应用拥有或负责销毁 Transport 对象,而是说:应用保存 installTransport 返回的后端指针,并绕过统一 Engine,直接在这个对象上完成 Batch 的分配、提交、查询与释放。
1 | Transport* xport = engine.installTransport("nvmeof", args); |
“保存指针”本身不是关键。关键是四个操作始终落在同一个 NVMeoFTransport 对象上:
- 分配时创建 NVMeoF 私有 descriptor;
- 提交时向 descriptor 填入 cuFile 参数;
- 查询时用 descriptor 中的 slice 映射聚合事件;
- 释放时回收 descriptor、BatchHandle 与私有上下文。
因此,更准确的说法是:这个 Batch 从创建到回收都由 NVMeoF 后端直接管理。 “持有后端指针”只是调用者进入这条专用生命周期的手段。
Classic 为什么直接调用能跑
可以先把 Transfer Engine 想成物流平台,把不同 transport 想成不同承运商。
公共 BatchDesc 是平台统一的订单,记录容量和 task 列表。NVMeoF 后端还需要一只专用文件袋,保存 cuFile descriptor、task 到 slice 的映射以及异步完成事件。代码里的这只“文件袋”就是 NVMeoFBatchDesc。
1 | BatchDesc 公共订单 |
直接调用 xport->allocateBatchID 时,执行的是 NVMeoFTransport::allocateBatchID。它先创建公共 Batch,再把私有对象放进 context:
1 | auto* nvme_batch = new NVMeoFBatchDesc(); |
后面的接口都能沿 batch.context 找回同一个 NVMeoFBatchDesc。cuFile handle、切片区间、事件缓存和资源回收因此连成了闭环。
这条专用路径能够工作,但代价也很直接:调用者必须知道自己正在使用 NVMeoF,并牢记这个 Batch 只能继续交给同一个后端。
统一 Engine 为什么反而断了
如果 Batch 由 engine->allocateBatchID 创建,执行者变成 MultiTransport。此时运行时还没有为 NVMeoF 建立私有上下文:
1 | engine->allocateBatchID |
等到 engine->submitTransfer 解析请求后,它确实可以根据 Segment protocol 选中 NVMeoFTransport。但 transport 选择解决的只是“任务应该交给谁”,没有补上“这个后端的批次状态放在哪里”。
1 | engine->submitTransfer |
旧实现只在专用的 NVMeoFTransport::allocateBatchID 中创建 descriptor;通用路径不会调用这个函数,而 submitTransferTask 又没有实现等价的私有批次分配、状态映射和回收逻辑,所以只能返回 NotImplemented。
Classic 难扩展在哪里
Classic 并不是不能装载多个 transport。困难出现在新后端需要自己的批次级私有状态时:公共 Engine 与后端必须临时商量“这份状态由谁创建、挂在哪里、什么时候可以释放”,但 Classic 没有把这套协商变成统一契约。
Batch 创建得太早
公共 Batch 在 transport 选择前已经存在。后端真正收到 task 时,Batch 的对象形态和所有权已经确定。
对只需逐 task 提交、几乎没有批次私有状态的后端,这未必构成问题。但 NVMeoF、GDS 这类异步批量后端还要维护:
- 底层 BatchHandle;
- 一个 task 展开后的多个物理 slice;
- slice completion 到公共 task 的映射;
- 取消后仍被设备引用的参数;
- 只有物理终态后才能回收的资源。
这些对象不能在提交函数的栈上临时创建,也不能在 API 返回错误后立即删除。它们需要一个与异步 Batch 同寿命的私有容器。
单个 context 难表达多个后端
Classic 的公共 BatchDesc 只有一个泛型 context。如果整个 Batch 永远只交给一个后端,这个槽位尚可保存私有对象;一旦公共 Batch 中的 task 需要按 transport 分组,问题就出现了:
1 | 一个公共 Batch |
一个 context 无法自然表达多组彼此独立的 handle、队列和完成事件。理论上可以再增加 map、外部 side table 或新的特殊分支,但每接一个有状态后端,公共层就更了解一种后端细节,扩展成本会继续向中心聚集。
后端知识泄漏给调用者
专用 xport 路径把本该由运行时处理的问题交给了业务代码:
- 应用要提前知道应该选择
nvmeof; - 应用要保存后端指针;
- Batch 必须由这个后端分配,也必须由它释放;
- 误用
engine->freeBatchID可能绕开私有资源回收; - 更换 transport 会改变业务代码的调用路径。
这使后端从“可替换的执行器”变成了“业务必须显式理解的对象”。后端数量增加后,统一 Engine 名义上仍在,真正的调度边界却散落到了调用者手中。
特殊路径会逐渐增多
如果每个新后端都通过专用分配接口解决自己的状态问题,系统最终容易形成多套彼此相似但不能互换的调用链:
1 | RDMA → 一套 Batch 前提与回收规则 |
这并不表示 Classic 无法继续增加代码,而是说每增加一个后端,都可能同时增加一套调用约定和生命周期例外。类数量在增长,统一抽象却没有同步变强。
TENT 改变了什么
TENT 没有要求所有后端把私有状态塞进同一个公共 Batch,也没有让应用直接保存 GdsTransport*。它在公共 Batch 与具体 transport 之间增加了明确的 SubBatch 层:
1 | 应用提交公共 Request |
对象关系也随之改变:
1 | TENT 公共 Batch |
每个 transport 可以定义自己的 SubBatch 类型,公共运行时只负责在正确时间调用统一生命周期接口。这样一来:
- 选择与分配顺序正确:先知道使用哪个后端,再让该后端创建私有状态;
- 私有状态彼此隔离:GDS 与 IOUring 不争用一个
context; - 调用者保持统一:应用只面对 Engine,不直接保存某个 transport 指针;
- 生命周期成为契约:分配、提交、查询与释放不再是后端的隐藏约定;
- 一个公共 Batch 可以容纳多个执行后端:每组 task 进入自己的 SubBatch,状态再聚合回公共 task。
TENT 的关键变化不是把 Transport 类改了一个名字,而是把“后端私有批次”提升成运行时的一等对象。
两套 Engine 放在一起看
| 维度 | Classic Engine | TENT Engine |
|---|---|---|
| 公共 Batch 创建时机 | 通常先创建,再在提交时选择 transport | 先准备请求和选择 transport,再创建私有 SubBatch |
| 后端私有状态 | 依赖公共 context、外部映射或特殊路径 |
每个 transport 拥有独立 SubBatch |
| 应用是否知道后端 | 专用路径需要保存 Transport* |
应用只调用统一 Engine |
| 多后端分组 | 后端私有批次缺少统一落点 | 公共 Batch 下可挂多个 transport SubBatch |
| 状态聚合 | 各后端自行适配公共 task,容易形成例外 | runtime 根据 SubBatch 与 task 映射统一推进 |
| 资源回收 | 调用者可能必须找回原后端 | runtime 协调 freeSubBatch |
| 新后端接入重点 | 容易先实现数据搬运,再补生命周期 | 接口直接要求完整异步生命周期 |
| 典型失败 | selector 选中后端,但提交接口 NotImplemented |
transport 未实现契约时不会形成完整可用路径 |
Classic 的优势是直接:后端掌握自己的对象,专用测试可以很快打通底层 I/O。TENT 付出的代价则是运行时更复杂,需要维护 selector、公共 task、SubBatch 和状态映射。
因此,这不是“旧架构一无是处、新架构天然正确”的比较。二者优化的阶段不同:Classic 更容易先证明某个 transport 能搬数据;TENT 更重视多个 transport 怎样长期共存、替换和回收。
新后端应先回答什么
接入一个新 NDS transport 时,可以先不看具体 SDK,而是检查下面五个问题:
- 谁选择后端:业务代码硬编码,还是 runtime 根据 Segment、内存与 capability 选择?
- 私有状态放在哪里:公共 Batch 的临时槽位,还是后端自己的稳定 SubBatch?
- 谁管理生命周期:调用者记住专用顺序,还是 Engine 统一调用分配、提交、查询与释放?
- 怎样与其他后端共存:一个 Batch 是否能够按 transport 分组,并保留多套私有状态?
- 失败后何时安全释放:API 返回错误时,设备是否仍可能引用参数与用户 buffer?
如果前三个问题的答案仍是“让业务代码拿着某个 backend 指针自行处理”,那么新增的只是一个能单独运行的后端,还没有成为统一运行时中的可替换能力。
最终判断
回到开头,“持有 Transport*”真正暴露的不是一个 C++ 指针问题,而是一条架构边界:Classic 的 NVMeoF 私有生命周期只存在于后端专用调用链中,没有成为公共 Engine 能够创建和管理的对象。
这解释了为什么专用测试能够成功,MultiTransport 也能选中 nvmeof,但通用提交仍然返回 NotImplemented。选择后端与管理后端生命周期,是两件不同的事。
TENT 用 SubBatch 把这两件事重新接了起来。现在可以确认的是,这种对象边界更适合多个有状态异步后端共存;但它并不消除后端实现本身的复杂度,每个 transport 仍必须正确处理切片、完成事件、取消和安全回收。
因此,新后端接入时最值得复制的不是某个 submit 函数,而是这条完整关系:公共请求由 runtime 选择和分组,私有状态由 transport 创建,最终状态再由 runtime 聚合和回收。
参考文献
Mooncake Classic vs TENT Engine